package pl.droidsonroids.gif;

import java.io.File;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.Locale;
import android.content.res.AssetFileDescriptor;
import android.content.res.AssetManager;
import android.content.res.Resources;
import android.content.res.Resources.NotFoundException;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
import android.widget.MediaController.MediaPlayerControl;

/**
 * A {@link Drawable} which can be used to hold GIF images, especially animations.
 * Basic GIF metadata can be also obtained.  
 * @author koral--
 */
public class GifDrawable extends Drawable implements Animatable, MediaPlayerControl
{
	static
	{
		System.loadLibrary( "gif" );
	}

	private static native void renderFrame ( int[] pixels, int gifFileInPtr, int[] metaData );

	private static native int openFd ( int[] metaData, FileDescriptor fd, long offset );

	private static native int openByteArray ( int[] metaData, byte[] bytes );

	private static native int openDirectByteBuffer ( int[] metaData, ByteBuffer buffer );

	private static native int openStream ( int[] metaData, InputStream stream );

	private static native int openFile ( int[] metaData, String filePath );

	private static native void free ( int gifFileInPtr );

	private static native boolean reset ( int gifFileInPtr );

	private static native void setSpeedFactor ( int gifFileInPtr, float factor );

	private static native String getComment ( int gifFileInPtr );

	private static native int getLoopCount ( int gifFileInPtr );

	private static native int getDuration ( int gifFileInPtr );

	private static native int getCurrentPosition ( int gifFileInPtr );

	private static native int seekToTime ( int gifFileInPtr, int pos, int[] pixels );

	private static native int seekToFrame ( int gifFileInPtr, int frameNr, int[] pixels );

	private static native int saveRemainder ( int gifFileInPtr );

	private static native int restoreRemainder ( int gifFileInPtr );

	private static native long getAllocationByteCount ( int gifFileInPtr );

	private static final Handler UI_HANDLER = new Handler( Looper.getMainLooper() );

	private volatile int mGifInfoPtr;
	private volatile boolean mIsRunning = true;

	private final int[] mMetaData = new int[ 5 ];//[w,h,imageCount,errorCode,post invalidation time]
	private final long mInputSourceLength;

	private float mSx = 1f;
	private float mSy = 1f;
	private boolean mApplyTransformation;
	private final Rect mDstRect = new Rect();

	/**
	 * Paint used to draw on a Canvas
	 */
	protected final Paint mPaint = new Paint( Paint.FILTER_BITMAP_FLAG | Paint.DITHER_FLAG );
	/**
	 * Frame buffer, holds current frame. 
	 * Each element is a packed int representing a {@link Color} at the given pixel.
	 */
	protected final int[] mColors;

	private final Runnable mResetTask = new Runnable()
	{
		@Override
		public void run ()
		{
			reset( mGifInfoPtr );
		}
	};

	private final Runnable mStartTask = new Runnable()
	{
		@Override
		public void run ()
		{
			restoreRemainder( mGifInfoPtr );
			invalidateSelf();
		}
	};

	private final Runnable mSaveRemainderTask = new Runnable()
	{
		@Override
		public void run ()
		{
			saveRemainder( mGifInfoPtr );
		}
	};

	private final Runnable mInvalidateTask = new Runnable()
	{
		@Override
		public void run ()
		{
			invalidateSelf();
		}
	};

	private static void runOnUiThread ( Runnable task )
	{
		if ( Looper.myLooper() == UI_HANDLER.getLooper() )
			task.run();
		else
			UI_HANDLER.post( task );
	}

	/**
	 * Creates drawable from resource.
	 * @param res Resources to read from
	 * @param id resource id
	 * @throws NotFoundException if the given ID does not exist.
	 * @throws IOException when opening failed
	 * @throws NullPointerException if res is null
	 */
	public GifDrawable ( Resources res, int id ) throws NotFoundException, IOException
	{
		this( res.openRawResourceFd( id ) );
	}

	/**
	 * Creates drawable from asset.
	 * @param assets AssetManager to read from
	 * @param assetName name of the asset
	 * @throws IOException when opening failed
	 * @throws NullPointerException if assets or assetName is null
	 */
	public GifDrawable ( AssetManager assets, String assetName ) throws IOException
	{
		this( assets.openFd( assetName ) );
	}

	/**
	 * Constructs drawable from given file path.<br>
	 * Only metadata is read, no graphic data is decoded here.
	 * In practice can be called from main thread. However it will violate
	 * {@link StrictMode} policy if disk reads detection is enabled.<br>
	 * @param filePath path to the GIF file
	 * @throws IOException when opening failed
	 * @throws NullPointerException if filePath is null
	 */
	public GifDrawable ( String filePath ) throws IOException
	{
		if ( filePath == null )
			throw new NullPointerException( "Source is null" );
		mInputSourceLength = new File( filePath ).length();
		mGifInfoPtr = openFile( mMetaData, filePath );
		mColors = new int[ mMetaData[ 0 ] * mMetaData[ 1 ] ];
	}

	/**
	 * Equivalent to {@code} GifDrawable(file.getPath())}
	 * @param file the GIF file 
	 * @throws IOException when opening failed
	 * @throws NullPointerException if file is null
	 */
	public GifDrawable ( File file ) throws IOException
	{
		if ( file == null )
			throw new NullPointerException( "Source is null" );
		mInputSourceLength = file.length();
		mGifInfoPtr = openFile( mMetaData, file.getPath() );
		mColors = new int[ mMetaData[ 0 ] * mMetaData[ 1 ] ];
	}

	/**
	 * Creates drawable from InputStream.
	 * InputStream must support marking, GifIOException will be thrown otherwise.
	 * @param stream stream to read from
	 * @throws IOException when opening failed 
	 * @throws IllegalArgumentException if stream does not support marking
	 * @throws NullPointerException if stream is null
	 */
	public GifDrawable ( InputStream stream ) throws IOException
	{
		if ( stream == null )
			throw new NullPointerException( "Source is null" );
		if ( !stream.markSupported() )
			throw new IllegalArgumentException( "InputStream does not support marking" );
		mGifInfoPtr = openStream( mMetaData, stream );
		mColors = new int[ mMetaData[ 0 ] * mMetaData[ 1 ] ];
		mInputSourceLength = -1L;
	}

	/**
	 * Creates drawable from AssetFileDescriptor.
	 * Convenience wrapper for {@link GifDrawable#GifDrawable(FileDescriptor)}
	 * @param afd source
	 * @throws NullPointerException if afd is null
	 * @throws IOException when opening failed
	 */
	public GifDrawable ( AssetFileDescriptor afd ) throws IOException
	{
		if ( afd == null )
			throw new NullPointerException( "Source is null" );
		FileDescriptor fd = afd.getFileDescriptor();
		mGifInfoPtr = openFd( mMetaData, fd, afd.getStartOffset() );
		mColors = new int[ mMetaData[ 0 ] * mMetaData[ 1 ] ];
		mInputSourceLength = afd.getLength();
	}

	/**
	 * Creates drawable from FileDescriptor
	 * @param fd source
	 * @throws IOException when opening failed
	 * @throws NullPointerException if fd is null
	 */
	public GifDrawable ( FileDescriptor fd ) throws IOException
	{
		if ( fd == null )
			throw new NullPointerException( "Source is null" );
		mGifInfoPtr = openFd( mMetaData, fd, 0 );
		mColors = new int[ mMetaData[ 0 ] * mMetaData[ 1 ] ];
		mInputSourceLength = -1L;
	}

	/**
	 * Creates drawable from byte array.<br>
	 * It can be larger than size of the GIF data. Bytes beyond GIF terminator are not accessed.
	 * @param bytes raw GIF bytes
	 * @throws IOException if bytes does not contain valid GIF data
	 * @throws NullPointerException if bytes are null
	 */
	public GifDrawable ( byte[] bytes ) throws IOException
	{
		if ( bytes == null )
			throw new NullPointerException( "Source is null" );
		mGifInfoPtr = openByteArray( mMetaData, bytes );
		mColors = new int[ mMetaData[ 0 ] * mMetaData[ 1 ] ];
		mInputSourceLength = bytes.length;
	}

	/**
	 * Creates drawable from {@link ByteBuffer}. Only direct buffers are supported.
	 * Buffer can be larger than size of the GIF data. Bytes beyond GIF terminator are not accessed.
	 * @param buffer buffer containing GIF data
	 * @throws IOException if buffer does not contain valid GIF data
	 * @throws IllegalArgumentException if buffer is indirect 
	 * @throws NullPointerException if buffer is null
	 */
	public GifDrawable ( ByteBuffer buffer ) throws IOException
	{
		if ( buffer == null )
			throw new NullPointerException( "Source is null" );
		if ( !buffer.isDirect() )
			throw new IllegalArgumentException( "ByteBuffer is not direct" );
		mGifInfoPtr = openDirectByteBuffer( mMetaData, buffer );
		mColors = new int[ mMetaData[ 0 ] * mMetaData[ 1 ] ];
		mInputSourceLength = buffer.capacity();
	}

	/**
	 * Frees any memory allocated native way.
	 * Operation is irreversible. After this call, nothing will be drawn.
	 * This method is idempotent, subsequent calls have no effect.
	 * Like {@link android.graphics.Bitmap#recycle()} this is an advanced call and 
	 * is invoked implicitly by finalizer.
	 */
	public void recycle ()
	{
		mIsRunning = false;
		int tmpPtr = mGifInfoPtr;
		mGifInfoPtr = 0;
		free( tmpPtr );
	}

	@Override
	protected void finalize () throws Throwable
	{
		try
		{
			recycle();
		}
		finally
		{
			super.finalize();
		}
	}

	@Override
	public int getIntrinsicHeight ()
	{
		return mMetaData[ 1 ];
	}

	@Override
	public int getIntrinsicWidth ()
	{
		return mMetaData[ 0 ];
	}

	@Override
	public void setAlpha ( int alpha )
	{
		mPaint.setAlpha( alpha );
	}

	@Override
	public void setColorFilter ( ColorFilter cf )
	{
		mPaint.setColorFilter( cf );
	}

	/**
	 * See {@link Drawable#getOpacity()}
	 * @return always {@link PixelFormat#TRANSPARENT}
	 */
	@Override
	public int getOpacity ()
	{
		return PixelFormat.TRANSPARENT;
	}

	/**
	 * Starts the animation. Does nothing if GIF is not animated.
	 * This method is thread-safe.
	 */
	@Override
	public void start ()
	{
		mIsRunning = true;
		runOnUiThread( mStartTask );
	}

	/**
	 * Causes the animation to start over. 
	 * If animation is stopped any effects will occur after restart.<br>
	 * If rewinding input source fails then state is not affected.
	 * This method is thread-safe.
	 */
	public void reset ()
	{
		runOnUiThread( mResetTask );
	}

	/**
	 * Stops the animation. Does nothing if GIF is not animated.
	 * This method is thread-safe.
	 */
	@Override
	public void stop ()
	{
		mIsRunning = false;
		runOnUiThread( mSaveRemainderTask );
	}

	@Override
	public boolean isRunning ()
	{
		return mIsRunning;
	}

	/**
	 * Returns GIF comment
	 * @return comment or null if there is no one defined in file
	 */
	public String getComment ()
	{
		return getComment( mGifInfoPtr );
	}

	/**
	 * Returns loop count previously read from GIF's application extension block.
	 * Defaults to 0 (infinite loop) if there is no such extension.
	 * @return loop count, 0 means infinite loop, 1 means one repetition (animation is played twice) etc.
	 */
	public int getLoopCount ()
	{
		return getLoopCount( mGifInfoPtr );
	}

	/**
	 * @return basic description of the GIF including size and number of frames
	 */
	@Override
	public String toString ()
	{
		return String.format( Locale.US, "Size: %dx%d, %d frames, error: %d", mMetaData[ 0 ], mMetaData[ 1 ], mMetaData[ 2 ], mMetaData[ 3 ] );
	}

	/**
	 * @return number of frames in GIF, at least one
	 */
	public int getNumberOfFrames ()
	{
		return mMetaData[ 2 ];
	}

	/**
	 * Retrieves last error which is also the indicator of current GIF status.
	 *  
	 * @return current error or {@link GifError#NO_ERROR} if there was no error 
	 */
	public GifError getError ()
	{
		return GifError.fromCode( mMetaData[ 3 ] );
	}

	/**
	 * An {@link GifDrawable#GifDrawable(Resources, int)} wrapper but returns null 
	 * instead of throwing exception if creation fails. 
	 * @param res resources to read from
	 * @param resourceId resource id
	 * @return correct drawable or null if creation failed
	 */
	public static GifDrawable createFromResource ( Resources res, int resourceId )
	{
		try
		{
			return new GifDrawable( res, resourceId );
		}
		catch ( IOException e )
		{
			//ignored
		}
		return null;
	}

	/**
	 * Sets new animation speed factor.<br>
	 * Note: If animation is in progress ({@link #draw(Canvas)} was already called)
	 * then effects will be visible starting from the next frame. Duration of the currently rendered frame is not affected.   
	 * @param factor new speed factor, eg. 0.5f means half speed, 1.0f - normal, 2.0f - double speed 
	 * @throws IllegalArgumentException if factor<=0
	 */
	public void setSpeed ( float factor )
	{
		if ( factor <= 0f )
			throw new IllegalArgumentException( "Speed factor is not positive" );
		setSpeedFactor( mGifInfoPtr, factor );
	}

	/**
	 * Equivalent of {@link #stop()}
	 */
	@Override
	public void pause ()
	{
		stop();
	}

	/**
	 * Retrieves duration of one loop of the animation.
	 * If there is no data (no Graphics Control Extension blocks) 0 is returned.
	 * Note that one-frame GIFs can have non-zero duration defined in Graphics Control Extension block, 
	 * use {@link #getNumberOfFrames()} to determine if there is one or more frames.
	 * @return duration of of one loop the animation in milliseconds. Result is always multiple of 10.
	 */
	@Override
	public int getDuration ()
	{
		return getDuration( mGifInfoPtr );
	}

	/**
	 * Retrieves elapsed time from the beginning of a current loop of animation.
	 * If there is only 1 frame, 0 is returned.
	 * @return elapsed time from the beginning of a loop in ms
	 */
	@Override
	public int getCurrentPosition ()
	{
		return getCurrentPosition( mGifInfoPtr );
	}

	/**
	 * Seeks animation to given absolute position (within given loop) and refreshes the canvas.<br>
	 * <b>NOTE: only seeking forward is supported.<b><br>
	 * If position is less than current position or GIF has only one frame then nothing happens.
	 * If position is greater than duration of the loop of animation 
	 * (or whole animation if there is no loop) then animation will be sought to the end.<br>
	 * NOTE: all frames from current to desired must be rendered sequentially to perform seeking.
	 * It may take a lot of time if number of such frames is large.
	 * This method can be called from any thread but actual work will be performed on UI thread.    
	 * @param position position to seek to in milliseconds
	 * @throws IllegalArgumentException if position<0
	 */
	@Override
	public void seekTo ( final int position )
	{
		if ( position < 0 )
			throw new IllegalArgumentException( "Position is not positive" );
		runOnUiThread( new Runnable()
		{
			@Override
			public void run ()
			{
				seekToTime( mGifInfoPtr, position, mColors );
				invalidateSelf();
			}
		} );
	}

	/**
	 * Like {@link #seekToTime(int, int, int[])} but uses index of the frame instead of time.
	 * @param frameIndex index of the frame to seek to (zero based)
	 * @throws IllegalArgumentException if frameIndex<0
	 */
	public void seekToFrame ( final int frameIndex )
	{
		if ( frameIndex < 0 )
			throw new IllegalArgumentException( "frameIndex is not positive" );
		runOnUiThread( new Runnable()
		{
			@Override
			public void run ()
			{
				seekToFrame( mGifInfoPtr, frameIndex, mColors );
				invalidateSelf();
			}
		} );
	}

	/**
	 * Equivalent of {@link #isRunning()}
	 * @return true if animation is running
	 */
	@Override
	public boolean isPlaying ()
	{
		return mIsRunning;
	}

	/**
	 * Used by MediaPlayer for secondary progress bars.
	 * There is no buffer in GifDrawable, so buffer is assumed to be always full. 
	 * @return always 100
	 */
	@Override
	public int getBufferPercentage ()
	{
		return 100;
	}

	/**
	 * Checks whether pause is supported.
	 * @return always true, even if there is only one frame
	 */
	@Override
	public boolean canPause ()
	{
		return true;
	}

	/**
	 * Checks whether seeking backward can be performed.
	 * Due to different frame disposal methods it is not supported now.
	 * @return always false
	 */
	@Override
	public boolean canSeekBackward ()
	{
		return false;
	}

	/**
	 * Checks whether seeking forward can be performed.
	 * @return true if GIF has at least 2 frames
	 */
	@Override
	public boolean canSeekForward ()
	{
		return getNumberOfFrames() > 1;
	}

	/**
	 * Used by MediaPlayer.
	 * GIFs contain no sound, so 0 is always returned.
	 * @return always 0
	 */
//	@Override
	public int getAudioSessionId ()
	{
		return 0;
	}

	/**
	 * Returns the minimum number of bytes that can be used to store pixels of the single frame.
	 * Returned value is the same for all the frames since it is based on the size of GIF screen. 
	 * @return width * height (of the GIF screen ix pixels) * 4 
	 */
	public int getFrameByteCount ()
	{
		return mMetaData[ 0 ] * mMetaData[ 1 ] * 4;
	}

	/**
	 * Returns size of the allocated memory used to store pixels of this object.
	 * It counts length of all frame buffers. Returned value does not change during runtime.
	 * @return size of the allocated memory used to store pixels of this object
	 */
	public long getAllocationByteCount ()
	{
		return getAllocationByteCount( mGifInfoPtr ) + mColors.length * 4L;
	}

	/**
	 * Returns length of the input source obtained at the opening time or -1 if 
	 * length is unknown. Returned value does not change during runtime. 
	 * For GifDrawables constructed from {@link InputStream} and {@link FileDescriptor} -1 is always returned.
	 * In case of {@link File}, file path, byte array and {@link ByteBuffer} length is always known.
	 * @return number of bytes backed by input source or -1 if it is unknown
	 */
	public long getInputSourceByteCount ()
	{
		return mInputSourceLength;
	}

	/**
	 * Returns in pixels[] a copy of the data in the current frame. Each value is a packed int representing a {@link Color}.
	 * @param pixels the array to receive the frame's colors
	 * @throws ArrayIndexOutOfBoundsException if the pixels array is too small to receive required number of pixels
	 */
	public void getPixels ( int[] pixels )
	{
		if ( pixels.length < mColors.length )
			throw new ArrayIndexOutOfBoundsException( "Pixels array is too small. Required length: " + mColors.length );
		System.arraycopy( mColors, 0, pixels, 0, mColors.length );
	}

	/**
	 * Returns the {@link Color} at the specified location. Throws an exception
	 * if x or y are out of bounds (negative or >= to the width or height
	 * respectively). The returned color is a non-premultiplied ARGB value.
	 *
	 * @param x    The x coordinate (0...width-1) of the pixel to return
	 * @param y    The y coordinate (0...height-1) of the pixel to return
	 * @return     The argb {@link Color} at the specified coordinate
	 * @throws IllegalArgumentException if x, y exceed the bitmap's bounds
	 */
	public int getPixel ( int x, int y )
	{
		if ( x < 0 )
			throw new IllegalArgumentException( "x must be >= 0" );
		if ( y < 0 )
			throw new IllegalArgumentException( "y must be >= 0" );
		if ( x >= mMetaData[ 0 ] )
			throw new IllegalArgumentException( "x must be < GIF width" );
		if ( y >= mMetaData[ 1 ] )
			throw new IllegalArgumentException( "y must be < GIF height" );
		return mColors[ mMetaData[ 1 ] * y + x ];
	}

	@Override
	protected void onBoundsChange ( Rect bounds )
	{
		super.onBoundsChange( bounds );
		mApplyTransformation = true;
	}
	/**
	 * Reads and renders new frame if needed then draws last rendered frame.
	 * @param canvas canvas to draw into
	 */
	@Override
	public void draw ( Canvas canvas )
	{
		if ( mApplyTransformation )
		{
			mDstRect.set( getBounds() );
			mSx = ( float ) mDstRect.width() / mMetaData[ 0 ];
			mSy = ( float ) mDstRect.height() / mMetaData[ 1 ];
			mApplyTransformation = false;
		}
		if ( mPaint.getShader() == null )
		{
			if ( mIsRunning )
				renderFrame( mColors, mGifInfoPtr, mMetaData );
			else
				mMetaData[ 4 ] = -1;

			canvas.scale( mSx, mSy );
			canvas.drawBitmap( mColors, 0, mMetaData[ 0 ], 0f, 0f, mMetaData[ 0 ], mMetaData[ 1 ], true, mPaint );

			if ( mMetaData[ 4 ] >= 0 && mMetaData[ 2 ] > 1 )
				UI_HANDLER.postDelayed( mInvalidateTask, mMetaData[ 4 ] );//TODO don't post if message for given frame was already posted
		}
		else
			canvas.drawRect( mDstRect, mPaint );
	}

	/**
	 * @return the paint used to render this drawable
	 */
	public final Paint getPaint ()
	{
		return mPaint;
	}

//	@Override
//	public int getAlpha ()
//	{
//		return mPaint.getAlpha();
//	}

	@Override
	public void setFilterBitmap ( boolean filter )
	{
		mPaint.setFilterBitmap( filter );
		invalidateSelf();
	}

	@Override
	public void setDither ( boolean dither )
	{
		mPaint.setDither( dither );
		invalidateSelf();
	}

	@Override
	public int getMinimumHeight ()
	{
		return mMetaData[ 1 ];
	}

	@Override
	public int getMinimumWidth ()
	{
		return mMetaData[ 0 ];
	}
}